'''
store-it - Simple static data storage on Google App Engine.
           See http://code.google.com/p/store-it/ for details.

Copyright(C) 2009, Sylvain Fourmanoit <syfou@users.sourceforge.net>.

This program is free software: you can redistribute it and/or modify it under
the terms of the GNU General Public License version 2, as published by
the Free Software Foundation.

This program is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
PARTICULAR PURPOSE.  See the GNU General Public License for more details.

You should have received a copy of the GNU General Public License Version 2
along with this program.  If not, see <http://www.gnu.org/licenses/>.
'''
#------------------------------------------------------------------------------
# Imports
#
from google.appengine.api import users
from google.appengine.api import memcache
from google.appengine.ext import webapp
from google.appengine.ext.webapp import template
from google.appengine.ext.webapp.util import run_wsgi_app
from google.appengine.ext import db

import os.path
import cgi
import mimetypes
import datetime
import hashlib
import random

#------------------------------------------------------------------------------
# Global Variables
#
paths = {
    'base' : '/',		# Adapt to match app.yaml
    'admin': 'admin',
    'list' : 'list'
    }

auth_timeout  = 120             # Authorization Cookie Timeout (in seconds)
cache_timeout = 3600            # Resources Expiration (in seconds)
show_index    = True            # If true, return automated files index, 
                                # instead of a HTML 404 error
default_index = 'index.html'    # Name of the default index file to seek 
                                # (can be None)
debug         = True            # Application Debugging Flag

mimes = {                       # Special mimes
    '.py'   : 'text/plain',
    '.svg'  : 'image/svg+xml',
    '.patch': 'text/plain',
    '.txt'  : 'text/plain; charset="UTF-8"',
    '.tex'  : 'text/plain; charset="UTF-8"',
    '.sh'   : 'text/plain',
    #'.html' : 'application/xhtml+xml',
    }

#------------------------------------------------------------------------------
# Path Manipulation Utils
#
class Paths(dict):
    def __init__(self, paths):
        dict.__init__(self, {'base': paths['base']})
        for k, v in paths.iteritems():
            if k != 'base':
                self[k] = self.join(v)

    def join(self, path):
        return os.path.normpath(os.path.join('/', self['base'], path))

    def compare(self, path1, path2):
        return os.path.normpath(path1) == os.path.normpath(path2)

    def dirwalk(self, path):
        walk = [paths['base']]
        walk.extend(self._dirwalk(path))
        return zip(walk, walk[1:])

    def _dirwalk(self, path):
        path = self.join(path)
        pfx = os.path.commonprefix([self['base'], path])
        head, tail = os.path.split(path[len(pfx):])
        head = [i for i in head.split('/') if len(i) > 0]
        for i in xrange(len(head)):
            yield os.path.join('/', self['base'], *head[:i+1])

paths = Paths(paths)

class Directory:
    def __init__(self, path):
        self._path = path

    @property
    def path(self):
        return self._path if self._path[-1] == '/' else self._path + '/'

    @property 
    def name(self):
        ret = os.path.basename(self._path)
        return ret if len(ret) != 0 else '/'

#------------------------------------------------------------------------------
# HTTP Protocol Utils
# 
def compute_range(range_header, data):
    '''
    Parse HTTP 1.x Range Header, and return appropriate data.
    See RFC 2616 (HTTP/1.1 spec) from W3 for details.
    '''
    def content_range(a, b, data):
        return 'bytes %d-%d/%d' % (a, b, data)

    unit, spec = range_header.strip().split('=')
    if unit != 'bytes':
        raise ValueError('unit in range is not bytes')
    if ',' in spec:
        raise ValueError('non canonical multiple intervals not supported')
    if len(spec) == 0:
        raise ValueError('empty no range spec')

    if spec[0] == '-':
        return (content_range(len(data) + int(spec), len(data)-1, len(data)), 
                data[int(spec):])
    elif '-' in spec:
        a, b = spec.split('-')
        if len(b) > 0:
            return (content_range(int(a), int(b), len(data)),
                    data[int(a):int(b)+1])
        else:
            return (content_range(int(a), len(data)-1, len(data)),
                    data[int(a):])
    else:
        raise ValueError('invalid range spec')

def compute_httpdate(date = None, offset = 0):
    '''Compute RFC 1123-Compatible Date'''
    return ((datetime.datetime.today() if date is None else date) +
            datetime.timedelta(seconds = offset)).strftime(
        '%a, %d %b %Y %H:%M:%S GMT')

def parse_httpdate(date):
    '''Parse an hopefully RFC 1123-Compliant date'''
    for fmt in ('%a, %d %b %Y %H:%M:%S GMT',
                '%A, %d-%b-%y %H:%M:%S GMT',
                '%a %b %d %H:%M:%S %Y'):
        try:
            return datetime.datetime.strptime(date, fmt)
        except ValueError:
            pass

def compute_mime(path):
    ext = os.path.splitext(path)[1]
    if ext in mimes:
        return mimes[ext]
    mime = mimetypes.guess_type(path)[0]
    if path.endswith('GNUmakefile'):
        mime = 'text/plain'
    elif mime is None:
        mime = 'application/octet-stream'
    return mime

#------------------------------------------------------------------------------
# HTML Templates
#
index_template = '''\
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE 
 html PUBLIC "-//W3C//DTD XHTML 1.1//EN"
 "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en">
<head>
  <title>Index</title>
  <style type="text/css">
/*<![CDATA[*/
  table              {width         : 100%;           }
  thead              {font-weight   : bold;           }
  h1, h2             {padding-left  : 0.5em; 
                      background    : #7e1df3;
                      color         : white;          }
  tbody > tr:hover,    
  tbody > tr:hover a {background    : black;   
                      color         : white;          }
  #footer            {font-size     : smaller;        }
  #footer img        {vertical-align: middle; 
                      border        : none;           }
  #session           {float         : right;          }
  #directories       {position      : fixed; 
                      opacity       : 0.8;
                      background    : white;          
                      top           : 0; 
                      right         : 0;
                      margin        : 0.5em;
                      padding       : 1em;                  
                      border-bottom : 1px solid black;}
/*]]>*/
  </style>
</head>
<body>
  <h1>Index of {{ path }}</h1>
  <div id="directories">
  {% for directory in directories %}
  <a href="{{ directory.path }}">{{ directory.name }}</a>
  {% if not forloop.last %}&gt; {% endif %}
  {% endfor %}
  </div>
  
  {% if dbfiles %}
  <table>
  <thead>
  <tr>
    <td>Created</td>
    <td>Size</td>
    <td>Checksum (SHA1)</td>
    <td>Filename</td>
  </tr>
  </thead>
  <tbody>{% for dbfile in dbfiles %}
  <tr>
    <td>{{ dbfile.ctime }}</td>
  {% if dbfile.content %}
    <td>{{ dbfile.size }}</td>
    <td>{{ dbfile.sha1 }}</td>
  {% else %}
    <td>&mdash;</td>
    <td>&mdash;</td>
  {% endif %}
    <td><a href="{{ dbfile.cpath }}">{{ dbfile.basename }}</a></td>
  </tr>
  {% endfor %}</tbody>
  </table>
  {% else %}
  <p><em>Empty directory</em>.</p>
  {% endif %}
  {% if is_admin %}
  <h2>Other Interfaces</h2>
  <ul>
    <li><a href="{{paths.admin}}">Administration</a></li>
    <li><a href="{{cookie_path}}">Authorization Cookie</a></li>
    <li><a href="{{paths.list}}">Full Listing</a></li>
  </ul>
  {% endif %}
  <hr />
  <div id="footer">
  {{ now }} &mdash; 
  <a href="http://code.google.com/p/store-it/">
    <img src="http://code.google.com/appengine/images/appengine-noborder-120x30.gif"
     alt="Powered by store-it, on Google App Engine" />
  </a>
  <span id="session">{{ session }}</span>
  </div>
</body>
</html>
'''

admin_template = '''\
<?xml version="1.0" encoding="us-ascii"?>
<!DOCTYPE 
 html PUBLIC "-//W3C//DTD XHTML 1.1//EN"
 "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en">
<head>
  <title>Administration</title>
</head>
<body>
  <h1>Administration</h1>
{% if is_admin %}
  {% if message %}<p>{{ message }}</p>{% endif %}
  <form action="/admin" method="post">
    <div>
      Authorization Cookie Path:
      <input type="text" name="cookie_path" value="{{ cookie_path }}" />
      <input type="submit" value="Submit" />
    </div>
  </form>
{% else %}
  <p>Error 403 - Forbidden Access. User {{ nickname }} 
     is not an administrator.</p>
{% endif %}
  <br /><hr />
  <div>
    <a href="{{ base_path }}">Back</a> |
    <a href="{{ logout_url }}">Logout</a>
  </div>
</body>
</html>
'''

#------------------------------------------------------------------------------
# Datastore Models and Template Bridges
#
class AdminInfo(db.Model):
    cookie_path = db.StringProperty(required = True)

class DBFile(db.Model):
    content = db.BlobProperty(default = None)
    directory = db.SelfReferenceProperty(collection_name='children')
    path = db.StringProperty(indexed = True)
    date = db.DateTimeProperty(auto_now_add = True)
    sha1 = db.StringProperty()

class DBFileInfo:
    '''
    A Simple Templating Proxy (see index_template)
    '''
    def __init__(self, dbfile):
        self.dbfile = dbfile

    def __getattr__(self, attr):
        if hasattr(self.dbfile, attr):
            return getattr(self.dbfile, attr)
        elif attr == 'size':
            size = len(self.dbfile.content)
            if size < 1024:
                return '%d B' % size
            elif size < 1024 * 1024:
                return '%d Kib' % (size / 1024)
            else:
                return '%.1f Mib' % (size / 1024. /  1024)
        elif attr == 'ctime':
            return compute_httpdate(date = self.dbfile.date)
        elif attr == 'basename':
            return os.path.basename(self.dbfile.path)
        elif attr == 'cpath':
            return (self.dbfile.path 
                    if self.dbfile.content is not None else 
                    self.dbfile.path + '/')
                    
        else:
            raise AttributeError('No such attribute')

#------------------------------------------------------------------------------
# Request Handler
#
class StoreHandler(webapp.RequestHandler):
    def dbfile(self, path):
        return db.GqlQuery(
            'SELECT * from DBFile WHERE path = :1', path)[0]
    
    def randpass(self):
        a = [[ i for i in 'aeiou'], 
             [chr(i) for i in xrange(ord('a'), ord('z')) 
              if chr(i) not in 'aeiouy']]
        return ''.join([a[i%2][random.randint(0, len(a[i%2])-1)] 
                        for i in xrange(11)])

    def admininfo(self):
        if hasattr(self, '_admininfo'):
            info = self._admininfo
        else:
            try:
                info = db.GqlQuery('SELECT * from AdminInfo')[0]
            except IndexError:
                info = AdminInfo(cookie_path = 'auth' + self.randpass())
                info.put()
        return info

    def check_root(self):
        try:
            root = self.dbfile(paths['base'])
        except IndexError:
            root = DBFile(path = paths['base'])
            root.put()
        return root

    def get(self):
        '''
        Request Dispatcher: prioritize getting the "static data"
        '''
        self.check_root()
        path = paths.join(self.request.path)
        try:
            dbfile = self.dbfile(path)
        except IndexError:
            dbfile = None

        if dbfile is not None:
            if dbfile.content is not None:
                return self.get_file(dbfile)
            elif show_index is True:
                if default_index is not None:
                    try:
                        return self.get_file(
                            self.dbfile(os.path.join(path, default_index)))
                    except IndexError: 
                        pass
                return self.get_index(dbfile)
            else:
                self.error(404)
        elif paths.compare(paths['admin'], self.request.path):
            return self.get_admin()
        elif paths.compare(paths['list'], self.request.path):
            return self.get_list()
        elif paths.compare(paths.join(self.admininfo().cookie_path), 
                           self.request.path):
            return self.get_auth()
        else:
            self.error(404)

    def get_list(self):
        if not self.check_auth(): return
        self.response.headers['Content-Type'] = 'text/plain'
        self.response.out.write('# Created\tSize\tSHA1\tPath\n')
        for dbfile in db.GqlQuery('SELECT * from DBFile ORDER BY path'):
            if dbfile.content is not None:
                self.response.out.write('%s\t%d\t%s\t%s\n' % (
                        dbfile.date.strftime('%s'), len(dbfile.content), 
                        dbfile.sha1, dbfile.path))

    def get_auth(self):
        auth = memcache.get('auth')
        if auth is None:
            auth = hashlib.sha1(str(random.random())).hexdigest()
        expiration = compute_httpdate(offset = auth_timeout)
        memcache.set(key = 'auth', value = auth, time = auth_timeout)

        self.response.headers['Content-Type'] = 'text/plain'
        self.response.headers['Set-Cookie'] = 'auth=%s; path=/; expires=%s' % (
            auth, expiration)
        self.response.out.write('Authorization: %s\nExpires: %s\n' % (
                auth, expiration))

    def get_admin(self):
        self.check_root()
        if not hasattr(self, 'admin_template'):
            self.admin_template = template.Template(admin_template)

        user = users.get_current_user()
        if user:
            admininfo = self.admininfo()
            message = None
            if users.is_current_user_admin():
                cookie_path = self.request.get('cookie_path')
                if cookie_path == '':
                    cookie_path = admininfo.cookie_path
                else:
                    if admininfo.cookie_path != cookie_path:
                        if cookie_path not in paths.values():
                            message =  ('New authorization cookie path ' +
                                        '(<em>%s</em>) saved.') % cookie_path
                            admininfo.cookie_path = cookie_path
                            admininfo.put()
                        else:
                            message = '<em>%s</em> is a reserved path.' % \
                                cookie_path
                            cookie_path = admininfo.cookie_path
                    else:
                        message = 'This path (<em>%s</em>) is already set.' % \
                            cookie_path
                    
            else:
                cookie_path = admininfo.cookie_path
                self.error(403, silent = True)
            
            self.response.headers['Content-Type'] = 'text/html'
            self.response.out.write(self.admin_template.render(
                    {'logout_url' : cgi.escape(users.create_logout_url('/admin')),
                     'base_path': paths['base'],
                     'cookie_path': cookie_path,
                     'is_admin': users.is_current_user_admin(),
                     'message': message,
                     'nickname': user.nickname() }
                    ))
        else:
            self.redirect(users.create_login_url(self.request.uri))

    def post(self):
        if paths.compare(paths['admin'], self.request.path):
            return self.get_admin()
        else:
            self.error(404)
  
    def get_index(self, dbfile):
        '''
        Generate a dynamic index
        '''
        if not hasattr(self, 'index_template'):
            self.index_template = template.Template(index_template)
        
        dbfiles = [DBFileInfo(item) for item in dbfile.children]

        directories = []
        if not paths.compare(paths['base'], dbfile.path):
            directories.append(paths['base'])
        directories.extend(paths._dirwalk(dbfile.path))
        directories.append(dbfile.path)
        directories = [Directory(item) for item in directories]

        user = users.get_current_user()
        is_admin = False;
        cookie_path = paths['base']
        if user:
            session = '<a href="%s">Logout</a> (<em>%s</em>)' % (
                cgi.escape(users.create_logout_url(self.request.path)),
                user.nickname())
            if users.is_current_user_admin():
                is_admin = True
                cookie_path = paths.join(self.admininfo().cookie_path)
        else:
            session = '<a href="%s">Login</a>' % \
                cgi.escape(users.create_login_url(self.request.path))

        self.response.headers['Content-Type'] = 'text/html'
        self.response.out.write(
            self.index_template.render(
                template.Context(
                    {'dbfiles': dbfiles,
                     'directories': directories,
                     'path': dbfile.path,
                     'session': session,
                     'is_admin': is_admin,
                     'cookie_path': cookie_path,
                     'paths': paths,
                     'now' : datetime.datetime.now()})))

    def head(self):
        path = paths.join(self.request.path)
        try:
            self.head_file(self.dbfile(path))
        except IndexError:
            self.error(404)

    def head_file(self, dbfile):
        if 'Range' in self.request.headers:
            # Partial Request
            try:
                content_range, content = compute_range(
                    self.request.headers['Range'], 
                    dbfile.content)
                self.response.headers['Content-Range'] = content_range
                self.response.set_status(206)
            except ValueError:
                # Failed: serve full content
                content = dbfile.content
        else:
            # Full Request
            content = dbfile.content

        self.response.headers['Content-Type'] = \
            compute_mime(dbfile.path)
        self.response.headers['Last-Modified'] = \
            compute_httpdate(date = dbfile.date)
        self.response.headers['Expires'] = \
            compute_httpdate(offset = cache_timeout)
        self.response.headers['ETag'] = \
            '"%s"' % dbfile.sha1
        if dbfile.path.endswith('.gz'):
            self.response.headers['Content-Encoding'] = gzip
        self.response.headers['Cache-Control'] = 'public'
        self.response.headers['Accept-Ranges'] = 'bytes'
        return content

    def get_file(self, dbfile):
        # Handle conditional caching...
        # ...by date
        if 'If-Modified-Since' in self.request.headers:
            modified = parse_httpdate(self.request.headers['If-Modified-Since'])
            if modified is not None and modified > dbfile.date:
                return self.error(304, silent = True)
        # ...by tag
        if 'If-None-Match' in self.request.headers:
            if self.request.headers['If-None-Match'].strip()[1:-1] == dbfile.sha1:
                return self.error(304, silent = True)

        self.response.out.write(self.head_file(dbfile))

    def check_auth(self):
        try:
            auth_new = self.request.cookies['auth']
        except KeyError:
            auth_new = None

        auth_old = memcache.get('auth')
        if auth_new is None or auth_new != auth_old:
            # Maybe are we logged in as an administrator?
            if not users.is_current_user_admin():
                self.error(403)
                return False
            else:
                return True
        else:
            memcache.set(key = 'auth', value = auth_new, time = auth_timeout)
            return True
            
    def put(self):
        # Check credentials
        if not self.check_auth(): return

        # Check filename availability
        if os.path.normpath(self.request.path) in paths.values():
            self.error(403)
            return

        path = paths.join(self.request.path)

        # Update data
        try:
            dbfile = self.dbfile(path)
        except IndexError:
            dbfile = None

        # Check it's not a directory
        if dbfile is not None and dbfile.content is None:
            self.error(403)
            return

        if len(self.request.body) > 0:
            # Create root dir, if needed
            b = self.check_root()
            
            # Recursively create directories, if needed
            for a, b in paths.dirwalk(path):
                a = self.dbfile(a)
                try:
                    b = self.dbfile(b)
                except IndexError:
                    b = DBFile(path = b, directory = a)
                    b.put()
                           
            # Create the file
            DBFile(content = self.request.body,
                   path = path,
                   directory = b,
                   sha1 = hashlib.sha1(self.request.body).hexdigest()).put()
            if dbfile is not None:
                dbfile.delete()

        self.response.headers['Content-Type'] = 'text/plain'
        self.response.out.write('%d bytes put.\n' % len(self.request.body))

    def delete(self):
        # Check credentials
        if not self.check_auth(): return
        
        # Check file existence
        try:
            dbfile = self.dbfile(paths.join(self.request.path))
        except IndexError:
            self.error(404)
            return

        self.response.headers['Content-Type'] = 'text/plain'

        # Delete file
        if dbfile.content is None:
            # Directory
            self.delete_dir(dbfile)
        else:
            # Regular file
            dbfile.delete()
        self.response.out.write('OK\n')

    def delete_dir(self, dbfile):
        for child in dbfile.children:
            if child.content is None:
                self.delete_dir(child)
            else:
                child.delete()
        dbfile.delete()

    def error(self, code, silent = False):
        webapp.RequestHandler.error(self, code)
        if silent is not True:
            self.response.headers['Content-Type'] = 'text/plain'
            self.response.out.write('%d - %s.\n' % (
                    code, 
                    {403: 'Forbidden', 
                     404: 'Not Found'}[code]))

#------------------------------------------------------------------------------
# Fire in the hole!
#
app = webapp.WSGIApplication([('^%s.*' % paths['base'], StoreHandler)],
                             debug = debug)

if __name__ == "__main__":
    run_wsgi_app(app)
